#!/usr/bin/python

#Things left on the todo list:
# 1: Handling "Hard-Failures" according to spec. Right now, we just die if we 
#    don't get an OK back. According to Last.FM, we need to wait for 3 'hard 
#    failures', and then re-do the handshake.  Not sure what to do if the hand-
#    shake fails more than 3 times
# 2: Double-check the threading is correct. Emperically, this is correct, but
#    it'd be nice to have a hard proof of it.
# 3: Error-checking the submissions before they go off.
# 4: UTF-8.  If the data coming back from mlib queries isn't straight up ascii,
#    urlencode barfs.  Need to feed things through unicode(string,"utf-8"),
#    maybe...
# 5: Update the client ID once Last.FM gives us one.

import xmmsclient, os, os.path, sys, time, urllib, hashlib, getopt, threading

PROGRAM_NAME="xmms2scrobbler-py"
DEFAULT_CONFIG="~/.config/xmms2/xmms2scrobbler.conf"

DEBUG=True
def set_debug(torf):
	global DEBUG
	DEBUG=torf
def print_debug(msg):
	if DEBUG:
		print "%s: %s"%(PROGRAM_NAME,msg)

def unicode_scrub(dic):
	"""
	Returns a dictionary similar to the passed in dictionary, but replacing all 
	string values with unicode(val,"utf-8").encode("utf-8")
	"""
	d = dict()
	for key in dic:
		if isinstance(dic[key],str):
			d[key] = unicode(dic[key],"utf-8").encode("utf-8")
		elif isinstance(dic[key],unicode):
			d[key] = dic[key].encode("utf-8")
		else:
			d[key] = dic[key]
	return d

class XmmsClientResultException(Exception):
	def __init__(self,result):
		self.result = result

class SubmissionHandler(threading.Thread):
	def __init__(self,scrobbler_instance):
		threading.Thread.__init__(self)
		self.scrobbler = scrobbler_instance
		self.stop = False
	def run(self):
		while True:
			self.scrobbler.new_play_added.acquire()
			#while there are no plays to be delt with, wait on the condition
			#One we have items to deal with, construct a Last.fm submission
			#message that includes each thing in the queue. If the submission
			#works, remove items from the queue. If it fails "acceptably", leave
			#them in the queue. If it fails "unacceptably", die.
			#Notes: Get a lock on the queue while checking and removing from it
			#but not while waiting for a response from Last.FM. Also need to check
			#for "Program over, wake up and quit".
			self.scrobbler.new_play_added.wait()
			while (self.stop == False) and (len(self.scrobbler.plays) <= 0):
				self.scrobbler.new_play_added.wait()
			self.scrobbler.new_play_added.release()
			if self.stop: return
			self.scrobbler.plays_lock.acquire()
			success = self.scrobbler.do_lfm_submission(self.scrobbler.plays)
			if success:
				self.scrobbler.plays = list()
			self.scrobbler.plays_lock.release()
		return

class Scrobbler():
	lastfm_api_location = 'http://post.audioscrobbler.com/'
	client_id = 'xsp'
	client_ver = '0.2'
	def __init__(self,xmms_path,config_file=None):
		self.xmms = xmmsclient.XMMS(PROGRAM_NAME)
		print_debug("Connecting to xmms2d instance")
		self.xmms.connect(xmms_path,self.__xmms2d_disconnect)
		print_debug("Connected. Setting up callbacks")
		#todo: Error checking on RV from callback hookup
		rv = self.xmms.broadcast_playback_current_id(self.__current_id_changed)
		#todo: Check for proxies
		self.opener = urllib.URLopener()
		self.username = None
		self.passwd = None
		self.session_id = None
		self.nowplaying_base = None
		self.submission_base = None
		if config_file is None:
			config_file = DEFAULT_CONFIG
		self.read_config(config_file)
		self.plays = list()
		self.plays_lock = threading.Lock()
		self.new_play_added = threading.Condition()
		print_debug("Setup done")
		self.do_lfm_handshake()
		self.last_played_id = None
		self.submit_thread = SubmissionHandler(self)
		self.submit_thread.start()
	def stop_loop(self):
		"""
		Calls XMMS.exit_loop() on the xmms client instance.
		"""
		print_debug("Stopping event loop")
		self.xmms.exit_loop()
	def shutdown(self):
		"""
		Calls self.stop_loop() and shuts down the submission handler.
		"""
		self.stop_loop()
		self.stop_submission_handler()
		print_debug("Exiting")
	def run_main_loop(self):
		"""
		Calls XMMS.loop() on the xmms client instance. Blocking.
		"""
		print_debug("Starting main loop")
		self.xmms.loop()
		print_debug("Main loop exited")
	def mlib_query(self,mlib_id):
		"""
		Calls XMMS.medialib_get_info(mlib_id) on the xmms client instance. Blocking.
		Returns a dict with the keys "artist","title","album","duration", and
		"tracknr". If the key wasn't found in the mlib query, the value is set
		to an empty string.
		"""
		result = self.xmms.medialib_get_info(mlib_id)
		result.wait()
		if result.iserror():
			raise XmmsClientResultException(result)
		rv = dict()
		track_info = result.value()
		keys = ["artist","title","album","duration","tracknr",'laststarted']
		for k in keys:
			rv[k] = track_info[k] if track_info.has_key(k) else ""
		return rv
	def read_config(self,filename):
		"""
		Read in the user info from the given file.
		Filename can be relative to the program location or absolute. Config
		items are of the form 'name = value' with any amount of whitespace
		(excluding line endings) separating the name and value.  Currently
		only looks for username and password.
		"""
		filename = os.path.abspath(os.path.expanduser(os.path.expandvars(filename)))
		cfg_file = open(filename)
		for line in cfg_file.readlines():
			foo = [l.strip() for l in line.split("=")]
			if foo[0] == "username":
				self.username=foo[1]
			elif foo[0] == "passwd":
				self.passwd = foo[1]
	def set_user_info(self,username,passwd):
		self.username = username
		self.passwd = passwd
	def stop_submission_handler(self):
		print_debug("Stopping submission handler")
		self.submit_thread.stop=True
		self.new_play_added.acquire()
		self.new_play_added.notify()
		self.new_play_added.release()
		self.submit_thread.join()
	def do_lfm_handshake(self):
		"""
		Contacts Last.FM and requests a session ID, nowplaying, and submission
		URLs..
		Data is stored in session_id, nowplaying_base, and submission_base
		respectively. Needs to have username and passwd set before being called
		"""
		if (self.username is None) or (self.passwd is None):
			raise Exception("User info not setup, call set_user_info first.")
		loc = self.__make_lfm_handshake()
		self.opener.cleanup()
		resp = [r.rstrip('\n') for r in self.opener.open(loc).readlines()]
		if len(resp) == 1:
			raise Exception("Last.FM bad response: %s"%resp[0])
		(self.session_id,self.nowplaying_base,self.submission_base) = resp[1:]
	def do_lfm_nowplaying_from_dict(self,track_info):
		self.do_lfm_nowplaying(track_info['artist'],track_info['title'],track_info['album'],track_info['duration']/1000,track_info['tracknr'])
	def do_lfm_nowplaying(self,artist,track,album='',length='',track_number='',mbrainz=''):
		if self.session_id is None:
			raise Exception("No session ID, call do_lfm_handshake first.")
		data = {'s':self.session_id,'a':artist,'t':track,'b':album,'l':length,'n':track_number,'m':mbrainz}
		print_debug("****NOWPLAYING**** Base URL: %s"%self.nowplaying_base)
		print_debug("****NOWPLAYING**** POST data: %s"%urllib.urlencode(unicode_scrub(data)))
		self.opener.cleanup()
		resp = [r.rstrip('\n') for r in self.opener.open(self.nowplaying_base,urllib.urlencode(unicode_scrub(data)))]
		if (len(resp) < 1):
			raise Exception("Bad response from Last.FM: %s"%str(resp))
		if resp[0] == 'BADSESSION':
			print_debug('Got BADSESSION, re-doing handshake')
			self.do_lfm_handshake()
			print_debug('handshake done, recursing')
			self.do_lfm_nowplaying(artist,track,album,length,track_number,mbrainz)
		elif resp[0] != 'OK':
			raise Exception("Bad response from Last.FM: %s"%str(resp))
	def __make_lfm_handshake(self):
		data = {'hs':'true','p':'1.2.1','c':self.client_id,'v':self.client_ver,'u':self.username}
		timestamp = int(time.mktime(time.localtime()))
		auth = hashlib.md5(hashlib.md5(self.passwd).hexdigest()+str(timestamp)).hexdigest()
		data['t']=timestamp
		data['a']=auth
		return self.lastfm_api_location+"?"+urllib.urlencode(unicode_scrub(data))
	def do_lfm_submission(self,submission_dict_list):
		if self.session_id is None:
			raise Exception("No session ID, call do_lfm_handshake first.")
		data = {'s':self.session_id}
		for index in xrange(len(submission_dict_list)):
			submission_dict = submission_dict_list[index]
			data['a[%d]'%index] = submission_dict['song_info']['artist']
			data['t[%d]'%index] = submission_dict['song_info']['title']
			data['i[%d]'%index] = submission_dict['song_info']['laststarted']
			data['o[%d]'%index] = submission_dict['source']
			data['r[%d]'%index] = submission_dict['rating']
			data['l[%d]'%index] = submission_dict['song_info']['duration']/1000
			data['b[%d]'%index] = submission_dict['song_info']['album']
			data['n[%d]'%index] = submission_dict['song_info']['tracknr']
			data['m[%d]'%index] = submission_dict['mbrainz']
		print_debug("****SUBMISSION**** Base URL: %s"%self.submission_base)
		print_debug("****SUBMISSION**** POST data: %s"%urllib.urlencode(unicode_scrub(data)))
		self.opener.cleanup()
		resp = [r.rstrip('\n') for r in self.opener.open(self.submission_base,urllib.urlencode(unicode_scrub(data)))]
		if len(resp)<1:
			raise Exception("Bad response from Last.FM: %s"%str(resp))
		elif resp[0] != 'OK':
			raise Exception("Bad response from Last.FM: %s"%str(resp))
		return True
	def __xmms2d_disconnect(self,client_instance):
		"""
		Callback fucntion to exit mainloop once daemon disconnects. Should
		this take arguments? If we switch from xmmsclient.XMMS.loop(), this
		needs to be updated as well.
		"""
		print_debug("xmms2d disconnected, exiting.")
		self.shutdown()
	def __current_id_changed(self,result):
		"""
		Callback function that gets called whenever xmms2d broadcasts a
		playback_current_id.  Note: last.fm would like us only to push out
		scrobbles after 4 minutes or halfway through the song. (to prevent
		updates due to song-skips).  Thoughts: Instead of scrobbling at
		each event, maybe keep a list of updates that periodically gets
		pushed back to last.fm. Also, how to keep track of song changes
		in music streams (current_id doesn't change)
		"""
		#right now, just print out the update
		print_debug("Current ID has changed to: %s"%str(result.value()))
		try:
			new_track_info = self.mlib_query(result.value())
			if (new_track_info["artist"] != '') and (new_track_info["title"] != ''):
				self.do_lfm_nowplaying_from_dict(new_track_info)
		except XmmsClientResultException, e:
			print_debug("medialib error getting info for track: %s"%result.get_error())
		if not (self.last_played_id is None):
			self.__update_plays(time.mktime(time.localtime()),self.last_played_id)
		self.last_played_id=result.value()
	def __playback_status_changed(self,result):
		"""
		Callback function that gets called whenever xmms2d broadcasts a 
		playback_status.  Use this to keep track of song playing habits
		"""
		if not (self.last_played_id is None):
			self.__update_plays(time.mktime(time.localtime()),self.last_played_id)
		return
	####README####
	# The criteria for scrobbling are:
	#   1. The track must be submitted once it has finished playing. Whether it 
	#      has finished playing naturally or has been manually stopped by the 
	#      user is irrelevant.
	#   2. The track must have been played for a duration of at least 240 
	#      seconds or half the track's total length, whichever comes first. 
	#      Skipping or pausing the track is irrelevant as long as the 
	#      appropriate amount has been played.
	#   3. The total playback time for the track must be more than 30 seconds. 
	#      Do not submit tracks shorter than this.
	#   4. Unless the client has been specially configured, it should not 
	#      attempt to interpret filename information to obtain metadata instead 
	#      of using tags (ID3, etc).
	# So it looks like we need to listen for broadcast_playback_current_id
	# and for broadcast_playback_status when it's PLAYBACK_STATUS_STOP.
	# Check the song that was just playing for when it was last started and the 
	# current time, get the delta and store the song_id and time_spent_playing
	# in a list of songs to process. On broadcast_playback_current_id, set up
	# new song id as the song that was just playing.
	# How to process the work? Last.FM says we should handle submission failure
	# due to network inaccessibility or anything that's not a "poorly formatted
	# request" error, and queue the submissions. They also want to keep these
	# updates in chronological order.  Perhaps a thread that gets woken up 
	# whenever a new track needs to be scrobbled, and keeps around the queue
	# when it fails reasonably.
	# Notes:
	# Song that get scrobbled need the following information passed to Last.FM:
	# artist.	(REQUIRED) Get this from medialib from mlib_id
	# title.	(REQUIRED) ditto
	# start.	(REQUIRED) timestamp of when it started playing, in seconds from 
	#			unix epoch, UTC. Again, some mangling of mlib_id entries
	# source	(REQUIRED) How the track got selected, can be  P (user choice),
	#			R (stream broadcast), E (non-Last.FM recom.), L (Last.fm recom)
	#			U (unknown).  This will probably be hardcoded P, perhaps we can
	#			figure out when it's a stream for R
	# rating	can be L (love), B (ban) S (skip). B and S can only be there if
	#			source was Last.fm and L requires a web service thingy as well
	#			leave it empty
	# length	(REQUIRED) length of the entire track, not just the delta
	#			only really required if source = P
	# album		Ditto from medialib. Can be empty if not known
	# number	Track number. Can be empty if not known
	# mbrainz	MusicBrainz track ID. Can be empty if not known.
	def __update_plays(self,timestamp,song_id):
		#song_id = self.last_played_id
		song_info = self.mlib_query(song_id)
		#this delta is in SECONDS, song durration is in miliseconds
		delta = int(timestamp)-int(song_info['laststarted'])
		if (delta > 240) or (((int(song_info['duration'])/1000)>30) and (delta > (int(song_info['duration'])/2000))):
			#Ok, we've  met the criteria for submission, we need the artist,
			#title,laststarted, length (in SECONDS), album, and track num, which 
			#we can get from the song_info dict, as well as source (P hardcoded 
			#for now), rating (leave empty), and mbrainz (leave empty)
			play = {'song_info':song_info,'source':"P",'rating':"",'mbrainz':""}
			self.plays_lock.acquire()
			self.plays.append(play)
			self.plays_lock.release()
			self.new_play_added.acquire()
			self.new_play_added.notify()
			self.new_play_added.release()

#### BEGIN MAIN ####
if __name__ == '__main__':
	scrob = None
	cfg_file = None
	for option in getopt.gnu_getopt(sys.argv,'',["debug","config="]):
		if len(option) > 0:
			if option[0] == "--config":
				cfg_file = option[1]
			elif option[0] == "--debug":
				set_debug(True)
	try:
		scrob = Scrobbler(os.getenv("XMMS_PATH"),cfg_file)
	except IOError, e:
		print print_debug("Failed to connect to xmms2d:" % e)
		sys.exit(1)
	try:
		scrob.run_main_loop()
	except KeyboardInterrupt, ki:
		print print_debug("Caught KeyboardInterrupt, exiting")
		scrob.shutdown()
